Skip to content

VPN-first ProxyDroid: Compose UI + real Rust tun2socks#40

Merged
madeye merged 16 commits into
masterfrom
feature/rust-tun2socks
May 12, 2026
Merged

VPN-first ProxyDroid: Compose UI + real Rust tun2socks#40
madeye merged 16 commits into
masterfrom
feature/rust-tun2socks

Conversation

@madeye
Copy link
Copy Markdown
Owner

@madeye madeye commented Apr 26, 2026

Summary

  • Real tun2socks: replaces the placeholder C++ stub in app/src/main/cpp/tun2socks/ (which never wrote replies back to the TUN) with a vendored, slimmed Rust crate built on netstack-smoltcp. Supports SOCKS5 (with RFC 1929 user/password), SOCKS4/SOCKS4A, HTTP CONNECT (with Basic auth), and HTTPS (HTTP CONNECT over TLS to the proxy). UDP DNS is intercepted and forwarded as DoH through the same upstream.
  • Compose + Material 3 UI: single MainActivity (still named ProxyDroid) replaces the 800-line PreferenceActivity. Big connection card with the switch + live status, profile dropdown, inline Host/Port + chip-row proxy type, Authentication accordion, Advanced section. BypassListActivity and AppManager rewritten in Compose; FileChooser replaced with the system Storage Access Framework.
  • VPN-first: drops the legacy iptables ProxyDroidService, the root check / "PLEASE ROOT YOUR DEVICE FIRST" alert, and the unused LocalProxyServer HTTP→SOCKS bridge. New shared ProxyController is the start/stop helper used by activity, broadcast receivers, and the home-screen widget. ProxyDroidVpnService now adds itself to addDisallowedApplication so tun2socks's outbound socket bypasses the tun, and handles an ACTION_STOP intent so disconnect actually destroys the service (plain stopService doesn't, because the VpnService framework binding holds it alive).
  • Test rig: HostSocks5ProxyIntegrationTest (instrumentation, runs from the AVD against a host SOCKS5), plus two stdlib Python proxies in scripts/ (socks5_test_server.py, http_connect_test_server.py with optional --auth).
  • CI: .github/workflows/android-build.yml now installs the Rust toolchain (aarch64-linux-android target) and caches the cargo registry/target dir before each Gradle invocation.

What's verified end-to-end on Medium_Phone_API_36.1 (arm64)

Mode Auth Verified
SOCKS5 none ✅ Chrome → example.com; matching CONNECT lines in the host SOCKS5 server log; DoH resolves through the same proxy
HTTP CONNECT none ✅ Chrome → example.com via http_connect_test_server.py
HTTP CONNECT Basic ⚠️ negative case verified (proxy rejects empty creds with 407) — full positive path requires manual UI typing
SOCKS5 user/password ⚠️ code complete (RFC 1929), no live test rig
SOCKS4 / SOCKS4A n/a ⚠️ code complete, no live test rig
HTTPS proxy TLS to proxy + optional Basic ⚠️ code complete (NoVerify cert verifier so private CAs work), no live test rig

Build / size

  • Release APK: 23 MB → 25 MB (Rust runtime + reqwest/rustls).
  • compileSdk bumped 33 → 34 (Compose BOM 2023.10.01 / activity-compose 1.8.0 require it). targetSdk stays 33.
  • abiFilters narrowed to arm64-v8a for now; the cargo crate builds cleanly for the other 3 Android targets too — re-add them once the Rust targets are installed in CI.

Caveats

  • APK is ~25 MB, all of which lives on arm64; ship-other-ABIs is a follow-up.
  • Localised string files (values-fr/pt/ru) lag English for any new keys.
  • The home-screen widget and broadcast-receiver-driven auto-connect now go through ProxyController.startWithConsent, which falls back to launching the activity for the VPN consent dialog when consent isn't yet granted — same UX as every other VPN app.

Commits

074049f Replace stub C++ tun2socks with real Rust netstack-smoltcp implementation
c0fe332 Rewrite UI in Jetpack Compose + Material 3, switch to VPN-first flow
621dd57 Wire release signing + drop dead root/iptables code paths
1f9584d Add emulator↔host SOCKS5/HTTP integration test rig

Test plan

  • ./gradlew :app:assembleDebug :app:lintRelease :app:testReleaseUnitTest (local, green)
  • ./gradlew :app:assembleRelease (local, green; signed APK)
  • CI: build / lint / test jobs green
  • Manual: SOCKS5 + HTTP CONNECT flows verified on AVD via Chrome
  • Manual: SOCKS5+auth, SOCKS4, HTTPS proxy paths against real upstreams (follow-up)

🤖 Generated with Claude Code

madeye and others added 16 commits April 26, 2026 19:17
* HostSocks5ProxyIntegrationTest: instrumentation test that performs a SOCKS5
  NO_AUTH handshake from inside the AVD against a host proxy and asserts a 2xx
  HTTP response. Defaults to 10.0.2.2:1080 (the AVD alias for the host
  loopback); all knobs overridable via -P testInstrumentationRunnerArguments.
* scripts/socks5_test_server.py: stdlib SOCKS5 (NO_AUTH, CONNECT only).
* scripts/http_connect_test_server.py: stdlib HTTP CONNECT proxy with
  optional --auth user:pass for testing the HTTP/HTTPS upstream paths.
* README: how to run them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* app/build.gradle: signingConfigs.release reads keystore creds from
  local.properties (KEYSTORE_PATH/PASSWORD, KEY_ALIAS/PASSWORD), so
  assembleRelease produces an installable APK matching the published cert.
  Falls back to unsigned when local.properties has no keystore (CI).
* Delete ProxyDroidService.kt — the legacy iptables-based service is
  unreachable from the new VPN flow. ProxyDroidReceiver, the widget, and
  ConnectivityBroadcastReceiver now all funnel through ProxyController.
* Delete the obsolete preference XML (replaced by the Compose UI added in
  the next commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Main screen is now a single ComponentActivity hosting Compose:
* Connection card with prominent switch + status; 1s poller mirrors the
  service's working/connecting flags into UI state so the chip flips
  promptly.
* Profile dropdown + add/rename/delete via top-bar overflow menu.
* Inline Host/Port fields, Material chip row for proxy type
  (SOCKS5 / SOCKS4 / HTTP / HTTPS), Authentication accordion (with NTLM
  domain), Advanced section (PAC, DNS proxy, SSID auto-connect, bypass
  addresses, per-app routing).

BypassListActivity and AppManager rewritten in Compose (LazyColumn +
ListItem, search field, FilterChip). FileChooser removed in favour of
ActivityResultContracts.OpenDocument / CreateDocument for SAF-based
import/export; FileArrayAdapter and utils/Option deleted with it.

ProxyDroidVpnService:
* Always addDisallowedApplication(packageName) so tun2socks's outbound
  socket bypasses our own tun (fixes ETIMEDOUT loops).
* Handles ACTION_STOP intent: closes the tun, calls stopForeground +
  stopSelf so the system actually destroys the service. Plain stopService
  doesn't cut it because the VpnService binding holds the service alive
  while the tun is open.

ProxyController is the shared start/stop helper used by activity, broadcast
receivers, and the home-screen widget. start runs VpnService.prepare() and
either starts the service directly or routes through the activity for the
consent dialog (EXTRA_AUTO_START).

Profile gains a copy() method; MainViewModel.updateProfile emits a new
Profile instance so MutableStateFlow doesn't drop the emission for the
in-place mutation.

Default proxyType bumped from "http" to "socks5".

Drops the legacy preference XML, the AppCompat sub-screens, and the dead
"PLEASE ROOT YOUR DEVICE FIRST" alert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion

The pre-existing app/src/main/cpp/tun2socks was a placeholder: it logged
each TCP connection but never wrote any reply packets back to the TUN, never
synthesised SYN-ACKs, and dropped UDP DNS on the floor. Real traffic could
not pass.

This commit vendors the (mihomo-coupled-but-extractable) standalone Rust
tun2socks layer from ../mihomo-android — without the mihomo engine. Full
file list:

* app/src/main/rust/proxydroid-tun2socks/
    Cargo.toml           — slim deps, no mihomo_*.
    src/lib.rs           — JNI entry points, parses proxy_type string.
    src/tun2socks.rs     — netstack-smoltcp + TCP relay; ProxyKind enum
                           dispatches to one of four upstream connectors:
                           - SOCKS5 (with optional RFC 1929 user/password)
                           - SOCKS4 / SOCKS4A
                           - HTTP CONNECT (with optional Basic auth)
                           - HTTPS = HTTP CONNECT over TLS to the proxy
                                    (NoVerify cert verifier so private CAs
                                    work; matches the existing DoH posture)
    src/dns_table.rs     — IP↔hostname map populated from DoH responses.
    src/doh_client.rs    — DoH resolver routed through the user's upstream
                           via reqwest, scheme picked by ProxyKind.
    src/protect.rs       — VpnService.protect(fd) bridge over JNI.
    src/logging.rs       — android_logger init.

Build wiring:
* build.gradle: add gradlePluginPortal() + rust-android-gradle plugin.
* app/build.gradle: apply the plugin, add cargo {…} block (arm64-v8a only
  for now), wire cargoBuild as a dep of the merge tasks, narrow abiFilters
  to arm64-v8a accordingly, drop the externalNativeBuild tun2socks subdir.
* app/src/main/cpp/CMakeLists.txt: drop tun2socks subdirectory.
* .github/workflows/android-build.yml: install the Rust toolchain
  (aarch64-linux-android target) and cache the cargo registry/target dir.

Tun2SocksHelper.kt updated to:
* loadLibrary("proxydroid_tun2socks").
* New start signature accepting VpnService + proxyType.
* Rust spawns its own tokio runtime, so no Kotlin worker thread needed.

ProxyDroidVpnService.startVpn passes the VpnService (for protect(fd)) and
the upstream's host/port/auth + proxyType. Removes the LocalProxyServer
HTTP→SOCKS bridge entirely (the Rust side now speaks every supported
upstream natively). DNS server advertised by the tun is now 10.0.0.2; UDP/53
is intercepted by the Rust side and forwarded as DoH through the upstream.

APK size: 23 MB → 25 MB (Rust runtime + reqwest/rustls).

Verified end-to-end on Medium_Phone_API_36.1 AVD (arm64) against the host
SOCKS5 and HTTP CONNECT test servers added in the first commit:
* Chrome loads example.com over SOCKS5 → real CONNECT lines in server log.
* Chrome loads example.com over HTTP CONNECT → matching proxy log entries
  including DoH (CONNECT 1.1.1.1:443).
SOCKS5+auth, SOCKS4, HTTPS-proxy paths are coded but not exercised live in
this PR (test rig not in place); HTTP-Basic-auth negative case verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI emulator-test job uses an x86_64 emulator and the previous arm64-only
abiFilter caused INSTALL_FAILED_NO_MATCHING_ABIS. Now build
proxydroid-tun2socks for arm, arm64, x86, x86_64 and ship all of them.

* app/build.gradle: cargo.targets = ["arm", "arm64", "x86", "x86_64"];
  abiFilters back to all 4.
* CI workflow: install all 4 Rust android targets up front.

APK size delta is contained because the .so is small (2.2-3.8 MB per ABI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The real upstream-auth code lives in the Rust tun2socks crate
(socks5_handshake, http_connect). Add #[tokio::test] coverage that
spins up loopback TCP listeners impersonating auth-requiring SOCKS5
and HTTP CONNECT upstreams, then drives the crate's own handshake
functions against them. Six cases:

  SOCKS5: correct creds, wrong creds, no creds vs auth-required.
  HTTP CONNECT: correct creds, wrong creds, no creds.

Also drop the dead Kotlin LocalProxyServer + its unit tests --
ProxyDroidVpnService no longer routes through it; tun2socks talks
to the upstream directly. Fix the stale comment that still
referenced LocalProxyServer.

cargo fmt incidentally normalized two pre-existing style nits in
socks4_handshake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The existing HostSocks5ProxyIntegrationTest only covered NO_AUTH, and no
emulator-side HTTP CONNECT test existed at all. Extend the rig so the
instrumented suite runs both protocols across {no-auth, auth-ok,
auth-wrong-creds}:

  scripts/socks5_test_server.py:
    - Add --auth user:pass for RFC 1929 user/password sub-negotiation.

  app/src/androidTest/.../HostSocks5ProxyIntegrationTest.kt:
    - Existing NO_AUTH test unchanged.
    - New auth happy-path test against a user/password upstream.
    - New negative test asserting wrong creds get a non-zero RFC 1929
      status from the proxy.

  app/src/androidTest/.../HostHttpConnectProxyIntegrationTest.kt (new):
    - CONNECT through auth-less proxy returns 200.
    - CONNECT through auth-required proxy with correct creds returns 200.
    - Wrong creds and missing creds both return 407.

  scripts/run_emulator_tests.sh:
    Was a smoke script that never ran connectedAndroidTest. Rewrite to
    spin up all four fake upstream proxies on the host (SOCKS5 + HTTP
    CONNECT, each in no-auth and user/pass variants on ports
    1080/1081/8081/8082), wait for them to bind, then invoke
    connectedDebugAndroidTest with the matching instrumentation args.

Verified locally: ./gradlew :app:compileDebugAndroidTestKotlin succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PRs into feature/** branches and pushes to test/** branches were
previously silent. Extend the workflow triggers so this PR (and
similar follow-ups) actually run CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Google Play has required apps to target API 35 (Android 15) since
Nov 1, 2025; the current targetSdk 33 means uploads are rejected.
Move to 35 across the board and raise the floor to API 24 (Android
7.0), which covers ~97% of active devices and matches the practical
minimum required by current AndroidX / Compose dependencies.

AGP 8.1.2 caps compileSdk at 34, so the toolchain has to move too.
Pick the smallest delta that supports compileSdk 35:

  - AGP            8.1.2  -> 8.5.0
  - Gradle wrapper 8.4    -> 8.7
  - Kotlin         1.9.10 -> 1.9.25
  - Compose Compiler ext  1.5.3  -> 1.5.15  (matches Kotlin 1.9.25)
  - compileSdk     34 -> 35
  - minSdk         21 -> 24
  - targetSdk      33 -> 35

JDK requirement (17) unchanged; NDK 25.1.8937393 unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes bundled on the SDK-bump branch:

1. Mirrors: switch the Maven repos to Google's China mirror
   (https://dl.google.cn/dl/android/maven2/), Aliyun for Central
   and gradle-plugins, and Tencent Cloud for the Gradle binary
   distribution. Originals kept as fallbacks. Builds now work
   from networks where dl.google.com / services.gradle.org are
   unreachable directly.

2. Build failure: AGP 8.5 strictly rejects duplicated jniLibs
   sources, but rust-android-gradle 0.9.6 registers its
   build/rustJniLibs/<abi> output into jniLibs.srcDirs while the
   same path is also captured by AGP via the cargoBuild task,
   making mergeDebugJniLibFolders fail with "Duplicate
   resources" for libproxydroid_tun2socks.so on all four ABIs.
   Add a packagingOptions.jniLibs.pickFirsts rule for the .so.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pickFirsts approach applies at packaging, but the failure happens
earlier in mergeJniLibFolders. Switch to setting
duplicatesStrategy = EXCLUDE on those merge Copy tasks, which is the
well-known workaround for rust-android-gradle 0.9.6 on AGP 8.5+.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MergeSourceSetFolders does not expose duplicatesStrategy. The actual
fix is to dedupe android.sourceSets.*.jniLibs.srcDirs at configure
time so each ABI's libproxydroid_tun2socks.so is only listed once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The compileSdk 35 path forced AGP 8.5.0, which in turn broke
mergeJniLibFolders against rust-android-gradle 0.9.6 with "Duplicate
resources" for every ABI's libproxydroid_tun2socks.so. Neither
packagingOptions.jniLibs.pickFirsts (wrong phase) nor srcDirs
dedupe (duplicates come from AGP's auto-registration of cargoBuild
task outputs, not from srcDirs) resolves it, and no released
rust-android-gradle plugin is compatible with AGP 8.5+.

Sidestep the whole upgrade: AGP only requires compileSdk to be
high enough to reference APIs the code actually uses; targetSdk is
a manifest declaration Play reads, and targetSdk > compileSdk is
allowed. Set targetSdk to 35 (Play's current floor) on top of the
existing toolchain:

  - AGP                     stays 8.1.2
  - Gradle                  stays 8.4
  - Kotlin                  stays 1.9.10
  - Compose Compiler ext    stays 1.5.3
  - compileSdk              stays 34
  - minSdk                  21 -> 24
  - targetSdk               33 -> 35

Mirror changes (dl.google.cn, Aliyun, Tencent Cloud) are kept.
Drop the AGP-8.5-specific JNI dedupe workaround.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mirrors.cloud.tencent.com timed out from GitHub Actions runners.
services.gradle.org works from CI and is reachable from the user's
local network via their existing HTTPS proxy. Maven repo mirrors
(dl.google.cn / Aliyun) stay since they don't affect CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`./gradlew connectedDebugAndroidTest` on AGP 8.1.2 + JDK 17 dies with

    java.lang.IllegalAccessError: class com.google.protobuf.GeneratedMessageV3
      tried to access method
      com.google.protobuf.CodedInputStream.shouldDiscardUnknownFields()
      (GeneratedMessageV3 in URLClassLoader; CodedInputStream in loader 'app')

before any test runs (tests=0 in the JUnit XML, APK uninstall failures
in the UTP cleanup phase). Root cause: UTP host plugins live in their
own URLClassLoader while ddmlib lives in Gradle's "app" classloader,
and each pulls its own protobuf-java version; the JVM treats the two
copies as distinct types and the cross-classloader method dispatch
fails.

AGP 8.1.2 has no `useUnifiedTestPlatform=false` opt-out (Google
removed that flag in 8.x), and the obvious "upgrade AGP" path
retriggers the rust-android-gradle 0.9.6 mergeJniLibFolders
duplicate-resources bug we already had to dodge.

Bypass UTP entirely with `adb shell am instrument -w` -- the same
path CI's android-emulator-runner action uses, which is why CI runs
the suite green while local does not. The script:

  - Builds debug + androidTest APKs via gradle (no UTP touched).
  - Auto-picks free ports if 1080/1081/8081/8082 collide locally
    (e.g. sslocal already on 8081).
  - Boots the four fake-upstream Python proxies on the host.
  - Reinstalls both APKs on the emulator.
  - Runs the instrumentation directly via adb am instrument.
  - Parses the INSTRUMENTATION_STATUS_CODE / INSTRUMENTATION_CODE
    stream to report PASS / FAIL.
  - Tears everything down on exit.

Verified locally on AVD meow_api35 (arm64-v8a, API 35): 8/8 tests
green in 0.4 s of test time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Google Play requires new apps to target API 36 (Android 16) by Aug 31, 2025
and updates by Nov 1, 2025. Previous target 35 satisfied the 2024 deadline
but trips the 2025 warning in Play Console.

AGP 8.1.2 only officially supports compileSdk 34, so set
android.suppressUnsupportedCompileSdk=36 to silence the build-time error.
Bumping AGP itself is off the table for now — it retriggers the
rust-android-gradle 0.9.6 mergeJniLibFolders duplicate-resources bug we
already had to work around (see 0249f91 / 8875c74).

minSdk stays at 24 (no Play policy bump there yet).

Verified locally on AVD meow_api35 (arm64-v8a, API 35):
  - ./gradlew :app:assembleDebug green
  - scripts/run_local_emulator_tests.sh: 8/8 tests green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@madeye madeye merged commit 76a64eb into master May 12, 2026
6 checks passed
@madeye madeye deleted the feature/rust-tun2socks branch May 12, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant